fix(desktop): deliver mid-session tail append after loading history - #4066
fix(desktop): deliver mid-session tail append after loading history#4066liugddx wants to merge 3 commits into
Conversation
|
This is a synthesis of the independent blind review by @Sol-404ARE at exact head What I checked myself:
Findings from Sol's review (file:line anchored): Standards — NO-GO — 1×P2 (worst P2)
No other Standards/code finding; the production increment reuses the existing Spec — NO-GO — 1×P2 (worst P2)
The happy path is otherwise correct: the new test creates Verification: What I did not judge: a full Gate: Standards 1×P2 (template/trailer) and Spec 1×P2 (re-anchor guard) remain; despite
|
When the resident transcript window has been trimmed off the tail (`#hasNewer === true`, e.g. after scrolling up to load older history so `#evictToBudget(..., 'newest')` runs), `DesktopTranscriptReplica.#catchUp()` short-circuited a Host `transcript_advanced` by bumping the durable watermark and publishing an empty change. A freshly persisted assistant message was therefore never delivered to an already-open consumer: the active session did not show the newest message until the user switched to another session and back, which rebuilt the replica at the tail via a fresh subscription. Re-anchor to the newest window instead — the same recovery a fresh subscription performs — so the append reaches open consumers live. Because the re-anchor now awaits a page load where the branch was previously synchronous, it opens an interleaving window: a concurrent `discard()` (memory reclaim for a non-visible session) can mark the replica non-resident while the page is in flight. Re-check `#resident` after the await, before mutating or publishing, mirroring the existing paged catch-up guard — otherwise the resolved page would repopulate durable state and resurrect a discarded replica past its memory bound. Adds two regression tests: one asserts a tail `advance()` reaches open consumers while a history window is resident; the other opens a history window, starts the re-anchor, `discard()`s while its page is pending, and asserts the resolved page neither publishes an upsert nor repopulates the replica. Both fail without their respective guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Generated-by: Claude Code
1c60402 to
c7401fe
Compare
|
Thanks — both findings were real; addressed at Spec P2 (re-anchor can resurrect a discarded replica). Confirmed. Making the Standards P2 (submission hygiene). Restored the repository PR template (Summary / Verification / AI use selection / Checklist / behavior field) and amended the commit to carry the Local re-verification on the amended head: |
Sweep the same post-await `#resident` invariant across the remaining transcript-replica path that awaited a page and then mutated without re-checking residency. `#loadBefore` installed a decoded older-history page after two awaits while only asserting `#closed`, so a concurrent `discard()` (memory reclaim for a non-visible session) landing during the load would be undone: the resolved page repopulated durable state and blew the memory bound, exactly like the re-anchor path. Re-check `#resident` before installing, matching `#replaceWithRange` and the paged catch-up. Adds a deferred-page regression that discards while a `loadBefore` page is in flight and asserts no publish and no repopulation; it fails without the guard. The other awaiting paths were reviewed and need no change: the paged catch-up already guards before and after its await, and the post-loop empty publish is unreachable once residency has flipped because the `expectedSequence` watermark check trips first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Generated-by: Claude Code
|
Follow-up (
The remaining awaiting paths were reviewed and need no change:
Transcript suite 18/18, observer 37/37, biome + typecheck clean. |
|
This is a synthesis of the independent blind review by @Sol-404ARE at exact head What I checked myself:
Findings from Sol's bounded review (prior Standards — NO-GO — 1×P2 + 1×P3 (worst P2)
Spec — NO-GO — 1×P2 (worst P2)
Verification: full/incremental What I did not judge: a true LRU-discard → page-resolve → correlation_changed → terminal-failure E2E with two observed Sessions was not executed beyond race-inspection — verification was by code inspection and the tests noted above. Gate: Standards 1×P2 (body staleness) + 1×P3 and Spec 1×P2 (catchUp guard) remain; despite
|
The blind-review sweep of this file surfaced a third instance of the "state can change across an await" class, this one production-reachable: the ordinary contiguous `#catchUp` loop awaits a `direction: 'newer'` page and, on resolve, its per-page callback returns early when a concurrent `discard()` (LRU reclaim triggered by another observed session) has flipped `#resident` to false. But `expectedSequence` is then left short of the watermark, so the post-loop check throws `correlation_changed` and the subscription owner drives the session terminal — turning a benign memory reclaim into a fatal error. Re-check `#resident` after the page loop, before the watermark check, so a discarded replica returns cleanly and a later resume re-runs catch-up. This mirrors the guards already added to `#replaceWithRange` and `#loadBefore`. Adds a deferred-page regression that discards while a contiguous catch-up page is in flight and asserts `advance()` resolves (rather than rejecting) with no repopulation; it fails without the guard (`advance()` rejects with the watermark error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Generated-by: Claude Code
|
Thanks for the third pass. Addressed the two P2s at Spec P2 — production-reachable contiguous That makes three Standards P2 — stale PR body. Fixed. Body now reflects the exact head: 19 tests, 3 production guards, 3 affected commits (all carrying the Standards P3 — test duplication (judgment). Acknowledged — the three discard-race regressions do repeat the five-message corpus, fake handle/decoder, and change collector. I deliberately kept each self-contained so its fixture documents the specific interleaving it exercises (tail re-anchor vs. older-history load vs. contiguous catch-up differ in bootstrap shape, budget, and which page is gated). Happy to extract a shared Verification on the new head |
|
This is a synthesis of the independent blind review by @Sol-404ARE at exact head What I checked myself:
Findings from Sol's bounded review (prior Standards — GO with non-blocking cleanup — 1×P3 (worst P3)
Runtime failure-state entropy is reduced; test-maintenance entropy is slightly increased. Spec — GO — 0 P0–P3
Previously closed Verification: incremental What I did not judge: a true LRU-discard during the contiguous Gate: Standards 1×P3 (non-blocking) and Spec 0 — no P0–P2,
|
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed the current head. The bug is reachable when an active Session has moved its resident transcript window into older history: advancing only the watermark publishes no message, so the open consumer cannot observe the new assistant reply.
Re-anchoring through the existing newest-range path is the right fix. It preserves one contiguous transcript authority instead of adding a parallel notification or cache state. The post-await residency checks also correctly close the discard races introduced by the asynchronous reload, preventing reclaimed replicas from being resurrected or driven terminal.
The focused regressions cover the delivery gap and all three discard interleavings. Exact-head CI is green. Looks good.
简体中文
问题路径真实:当前会话加载过较早历史后,缓存窗口不在尾部;旧代码只推进水位却不发布消息,因此新回复无法到达已打开的页面。
复用现有最新窗口重新锚定是正确方案,保持单一连续 transcript authority。异步读取后的 residency 检查也完整覆盖了 discard 竞态,避免已回收缓存被复活或误入 terminal。相关回归测试和当前 CI 均通过。
Summary
In the desktop app, continuing a conversation in the currently active session could leave a newly arrived assistant message invisible until you switched to another session and back.
Root cause: once the resident transcript window has been trimmed off the tail —
#hasNewer === true, which happens after you scroll up to load older history (#loadBefore→#evictToBudget(..., 'newest')) —DesktopTranscriptReplica.#catchUp()handled a Hosttranscript_advancedby bumping the durable watermark and publishing an empty change:targetcannot be appended contiguously to the trimmed window, so an already-open consumer (the active session's renderer range store) never learned about the new message, and the event-drivenrefreshMessagestimed out onwaitForDurableMessagewithout callingsetMessages. Switching sessions rebuilt the subscription and re-anchored at the tail, which is why switching away and back made the message appear.This re-anchors to the newest window instead — the same recovery a fresh subscription performs — so the append reaches open consumers live:
Concurrency hardening (review-driven sweep)
Making that branch
awaita page load turned a previously synchronous path async, opening an interleaving window: a concurrentdiscard()(LRU memory reclaim for a non-visible session — it does not go through the operation queue) can mark the replica non-resident while a page is in flight. On resolve, repopulating durable state would resurrect a deliberately discarded replica past its memory bound, and in one path throw a fatal error.Prompted by review, I swept every awaiting path in this file for the same class and re-check
#residentafter theawait, before mutating or publishing, in all three:#replaceWithRange(the re-anchor above) — resolved page would repopulate durable state and resurrect the replica.#loadBefore(older-history load) — same repopulation on the resolved older page.#catchUp(ordinary contiguous append) — the per-page callback already returned early, butexpectedSequencewas then left short of the watermark, so the post-loop check threwcorrelation_changedand the subscription owner drove the session terminal — turning a benign reclaim into a fatal error. This one is production-reachable.Verification
Local deps in my workspace have drifted ahead of this branch's base, so the full-project
build:mainno longer typechecks some unrelated management/SSH test files (missingdeploymentId, widened action unions). The changed files and their transitive imports compile cleanly in isolation; CI (test) is green at the exact head.tscon the changed files + their imports (isolated project) — cleannpx biome checkon the two changed files — cleannode --test desktop-transcript-range-store.test.js— 19/19 (includes three discard-race regressions)node --test runtime-host-session-observer.test.js— 37/37 (from a matching-deps run)Each guard is a genuine regression — confirmed to fail with the guard removed and pass with it restored:
advance()publishes no upsert.#residentre-check the resolved page repopulates durable state (durableUpserts = [5],residentBytesgrows back).advance()rejects with the watermarkcorrelation_changed.Review focus
The behavioral change is scoped to the
#hasNewercatch-up branch (long sessions where you have scrolled up past the resident cache). Sessions that stay within the cache take the ordinary contiguous-append path and are unaffected. The three#residentre-checks are no-ops on the happy path; they only make a mid-flightdiscard()a clean no-op instead of a resurrection or a fatal error.AI use
Select exactly one:
Tool(s) and scope: Claude Code — diagnosis of the delivery gap and the discard race, the production guards in
desktop-transcript-replica.ts, and the regression tests. Reviewed and owned by the human contributor of record. All three affected commits carry aGenerated-by: Claude Codetrailer.Checklist
Does this PR entail a change in behavior?